今天先讓 QEMU 停在沒有開機文字的狀態。
不是核心壞掉,而是我們要趕在主控台初始化以前,看看 CPU 的堆疊與權限模式如何準備完成。
昨天固定了 xv6 版本,今天沿用同一個 checkout。
實驗會依序停在 _entry、start() 與 main(),再觀察分頁啟用前後的 satp。
《Operating System Concepts》第 10 版第 2.9.2 節說明 bootstrap 程式如何載入核心,接著由核心完成系統初始化。
在這次 QEMU 設定中,實際要追的接手順序是:
QEMU reset code
→ _entry:選擇本 hart 的啟動堆疊
→ start():準備 S-mode 環境
→ mret
→ main():初始化核心服務
→ scheduler()
entry.S 是檔名,真正的入口符號叫 _entry。
這份 xv6 透過 -bios none 啟動,不經過前面 Tiny Kernel 使用的 OpenSBI。
所以 start() 必須自己完成一部分 M-mode 設定,不能沿用「核心一開始已經在 S-mode」的假設。
終端機 A 位於 30-days-os-kernel/examples/xv6-riscv/,先確認已完成 Day 19 的編譯:
make -j4 TOOLPREFIX=riscv64-linux-gnu- kernel/kernel fs.img
qemu-system-riscv64 \
-machine virt -bios none -kernel kernel/kernel \
-m 128M -smp 1 -nographic \
-global virtio-mmio.force-legacy=false \
-drive file=fs.img,if=none,format=raw,id=x0 \
-device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 \
-S -gdb tcp:127.0.0.1:1234
-S 讓 CPU 等待除錯器,-gdb 則建立連線入口。
GDB 介面沒有登入驗證,因此只綁定 loopback,不開放給外部網路。
若 port 1234 已被其他實驗占用,請同時調整 QEMU 與 GDB 的連線設定。
終端機 B 也位於同一個 xv6 checkout:
gdb-multiarch -nx kernel/kernel -x ../xv6/boot.gdb
-nx 避免載入個人或 repository 的初始化指令,這次需要的設定全部放在 boot.gdb。
腳本只連線並停在 _entry,後面的檢查由我們逐步執行。
以下列出預期的檢查關係,位址與 GDB 顯示格式請以自己環境為準。
在 GDB 輸入:
x/10i $pc
info registers pc sp
p/x $mhartid
thbreak *start
continue
info registers pc sp
p/x &stack0
p/x (unsigned long)$sp - (unsigned long)&stack0
第一個停點在 _entry,此時 xv6 的堆疊尚未設定。
第二個停點使用 *start,停在函式第一條指令,避免 C prologue 先改變 sp。
entry.S 用 mhartid 計算堆疊頂端:
stack0 + (hartid + 1) × 4096
單 hart 測試的 hartid 為 0,進入 start 時,sp 與 stack0 起點之差應為 0x1000。
這裡的 4 KiB 是每個 hart 的啟動堆疊,不是每個行程日後使用的 kernel stack。
閱讀 kernel/start.c,本版本會準備 mstatus.MPP 與 mepc,關閉早期分頁,設定例外與中斷委派,並以 PMP 允許 S-mode 存取所需實體記憶體。
它也設定 timer 相關功能,再把 hartid 放進 tp,供後續 cpuid() 使用。
最後的 mret 不是一般 C return。
CPU 依 MPP 選擇返回權限,依 mepc 選擇下一個 PC,因此會從 M-mode 進入 S-mode 的 main()。
在 GDB 接著輸入:
disassemble /r start
thbreak *main
continue
info registers pc tp
p/x $mepc
p/x &main
p/x $satp
此時應已抵達 main,tp 對應目前 hart,satp 仍為 0。
可將 start 的反組譯與最後的 mret 對照,不要把返回後的 MPP 欄位當成「目前權限模式」直接解讀。
若 GDB 版本有提供 $priv 暫存器,也可額外讀取它,但本文的操作不依賴這個非通用顯示介面。
本次固定的 xv6 使用 Sstc 相關設定與 stimecmp 安排 timer,不是所有舊版教學中的 M-mode timer handler 路線。
遇到程式碼不同時先檢查 commit,而不是立刻修改核心。
main() 先呼叫 kvminit() 建立核心頁表,再呼叫 kvminithart() 讓目前 hart 啟用它。
在 GDB 繼續:
thbreak kvminithart
continue
p/x $satp
finish
p/x $satp
p/x ((unsigned long)$satp >> 60)
第一次讀到的 satp 應仍為 0,完成函式後 MODE 欄位應是 8,也就是 Sv39。
低位元包含頁表根的實體頁號,其數值會隨建置與配置結果不同,不必和別人的截圖完全一致。
這也再次說明 Day 15 的頁表概念:把 PTE 寫進 RAM 還不等於 CPU 已經開始使用它。
核心必須更新對應控制暫存器,並完成必要的位址轉譯同步。
想保留文字證據,可以在操作前啟用 GDB logging:
set logging file day20-boot.log
set logging overwrite off
set logging enabled on
完成後使用 set logging enabled off 關閉紀錄,再 continue 讓 xv6 進入 Shell。
離開實驗時可先在 GDB 中斷、detach 並 quit,再到 QEMU 終端機按 Ctrl+A 後按 X。
結束單 hart 除錯後,在 xv6 checkout 執行一般三 hart 版本:
make TOOLPREFIX=riscv64-linux-gnu- CPUS=3 qemu
除了開機標語,應可看到其他 hart 的 starting 訊息,先後順序不必固定。
這段操作觀察的是多 hart 啟動,不是對整個核心完成並行正確性測試。
在 main.c 中,hart 0 建立配置器、行程表與裝置等共用結構,最後以 release store 更新 started。
其他 hart 用 acquire load 等待,再各自安裝頁表與 Trap 等每 hart 狀態。
最後每個 hart 都進入 scheduler(),而不是只讓 hart 0 負責執行所有行程。
這裡的 acquire/release 是記憶體順序語意,不是呼叫 xv6 spinlock 的 acquire()/release()。
兩者都與同步有關,但不能只看名字就混為同一個機制。
本日新增 examples/xv6/boot.gdb,並把實際的堆疊差值與 satp 觀察保存在自己的 log。
今天不需要在早期開機處塞入大量 printf,因為主控台尚未就緒時,除錯器才是較直接的觀察入口。
建議 commit:
day20: add reproducible xv6 boot debugging checkpoints
Day 21 將離開初始化流程,進入 struct proc,觀察 xv6 如何保存一個真正可執行的行程。